/**
 * 自动部署 v2.1
 *
 * 参考: https://blog.csdn.net/weixin_34893782/article/details/119363476
 * 注意安装依赖
 * npm i ssh2 compressing commander -D
 */

const {exec} = require('child_process')
const compressing = require('compressing')
const {Client: SSH2Client} = require('ssh2')
const {program, Option: CommanderOption} = require('commander') //@doc https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md
const path = require('path')
const fs = require('fs')

// 配置
const config = {
    // 生产环境
    prod: defineConfig({
        // 服务器信息
        server: {
            host: 'xxx.xx.xxx.xxx', // 服务器地址
            port: 22, // 端口号
            username: 'root', // 用户名
            password: 'xxx', // 密码
        },
        // 服务器部署目录，结尾带不带/都可以
        remote_dir: '/www/wwwroot/www.aa.com/public/admin',
    }),
    // 还可以配置多个环境的，使用的时候通过 --env= 来切换，例如在package.json中可以写指令
    // deploy: "node deploy.js && node deploy.js --no-build --env=demo"  这就可以打包一次发布两个环境
    // demo:defineConfig({ ... })
}

function defineConfig(config) {
    return Object.assign(
        {
            build_cmd: 'npm run build',
            build_out_dir: './dist',
            zip_temp_name: './dist-temp.zip',
        },
        config
    )
}

/**
 * ==== 部署配置 ===
 * 以下是一个完整的示例
const config = {
	prod: {
		// 服务器信息
		server: {
			host: 'xxx.xx.xxx.xxx', // 服务器地址
			port: 22, // 端口号
			username: 'root', // 用户名
			password: 'xxx', // 密码
		},

		// 服务器部署目录，结尾带不带/都可以
		remote_dir: '/www/wwwroot/www.aaa.com/public/admin',
		// 打包的指令
		build_cmd: 'npm run build',
		// 打包目录（即运行打包指令后，vue会生成的打包结果目录，根据vue的打包配置来设置，默认是 ./dist）
		build_out_dir: './dist',
		// 压缩包临时存放文件名（含路径）
		zip_temp_name: './dist-temp.zip',

		// PM2 相关配置
		pm2: {
			//pm2启动配置文件名，如果不设置，默认就是 ecosystem.config.js，也可以是一个相对路径（相对于打包目录的）
			config_file_name: 'ecosystem.config.js',
			// 上传完成后要执行的指令（一般是重启pm2）
			after_cmd: 'pm2 start',
			// pm2启动配置文件内容，会在打包后自动写入到打包目录的pm2配置文件中，随项目一起上传到服务器
			config_file_content: {
				apps: [
					{
						name: 'demo-01',
						script: './server/index.mjs', // 启动文件在配置文件的相对位置
						env: {
							NITRO_PORT: 3070, // 服务启动端口
						},
					},
				],
			},
		},
	},
}
 */

/**
 * ==== 解析参数 ====
 */
const configEnvKeys = Object.keys(config)
program
    .name('node deploy.js')
    .description('auto build and deploy to server')
    .addOption(new CommanderOption('-e, --env [env]', '环境参数，相当于采用config中的哪个配置项').choices(configEnvKeys).default(configEnvKeys[0]))
    .option('-c --cmd <command>', '手动要执行的远程指令，例如启停pm2等。如果设置了这一项，则不会执行其他任何操作')
    .option('--no-alert', '不打印打包过程中的输出')
    .option('--no-build', '跳过打包步骤，直接部署')
    .option('--no-zip', '跳过压缩，直接上传目录')
    .parse(process.argv)

const options = program.opts()

/**
 * [ ========== 执行入口函数 ========== ]
 * 程序就这一行代码，下面都是封装的函数
 * runForStatic() 和 runForSSR() 是总执行函数
 * 其他函数均是工具函数，无相互依赖
 * 如果是nuxt这种需要运行pm2的项目，改为执行 runForSSR() 即可
 */
if (require.main === module) {
    main(options, config)
}

/** 主函数 */
async function main(options, config) {
    config = config[options.env]
    if (!config) {
        throw new Error('请定义config并设置正确的环境参数')
    }

    // 单独执行命令
    if (options.cmd) {
        const connect = new SSH2Client()
        return new Promise(async (resolve, reject) => {
            connect
                .on('ready', () => shellExecCmd(connect, options.cmd, true))
                .on('error', (err) => {
                    console.error(`[!!!] 连接远程服务器出错:${err}`)
                    reject()
                })
                .connect(config.server)
        })
    }

    // 开始计时
    let time = getDisplayTime(new Date())
    console.log(`[--------------------] 开始执行部署 env=${options.env} ${time.start()}`)

    // 1. 打包
    if (options.build) {
        console.log(`[**------------------] 开始打包 [${time.next()}ms]`)
        await new Promise((resolve, reject) => {
            const buildCmd = config.build_cmd
            const cp = exec(buildCmd, (err, stdout, stderr) => {
                if (err) {
                    console.error(`[!!!] 执行打包出错: ${err}`)
                    return reject(err)
                }
                console.log(`[******--------------] 打包完成 [${time.next()}ms]`)
                resolve(true)
            })
            if (options.alert) cp.stdout.on('data', (data) => process.stdout.write(data))
        })
    }

	// 本地打包结果目录
    const buildOutDir = path.resolve(config.build_out_dir)
    if (!buildOutDir || !fs.existsSync(buildOutDir)) throw new Error(`本地打包结果目录不存在，请检查: ${config.build_out_dir}`)

    // 1.2 pm2配置文件准备
    if (config.pm2) {
        // pm2文件本地名称含路径
        const pm2ConfigFile = path.join(config.build_out_dir, config.pm2.config_file_name)
        // 写入本地文件
        fs.writeFileSync(pm2ConfigFile, `module.exports = ${JSON.stringify(config.pm2.config_file_content)}`, 'utf-8')
        console.log(`[************--------] 配置pm2启动文件完成 [${time.next()}ms]`)
    }

    // 2. 压缩
    if (options.zip) {
        const zipFileName = path.resolve(config.zip_temp_name) // zip文件目录

        // 检查zip目录
        const zipFileDir = path.dirname(zipFileName)
        if (!fs.existsSync(zipFileDir)) fs.mkdirSync(zipFileDir, {recursive: true})

        // 这个 ignoreBase 配置项好难找，文档里竟没有，它能忽略打包的根目录 @https://www.npmjs.com/package/compressing
        await compressing.zip.compressDir(buildOutDir, zipFileName, {ignoreBase: true})

        console.log(`[**********----------] 压缩完成 [${time.next()}ms]`)
    }

    // 3. 连接服务器
    const connect = new SSH2Client()
    await new Promise((resolve, reject) => {
        connect
            .on('ready', () => resolve())
            .on('error', (err) => {
                console.error(`[!!!] 连接远程服务器出错:${err}`)
                reject()
            })
            .connect(config.server)
    })
    console.log(`[**************------] 连接服务器成功 ${config.server.host} [${time.next()}ms]`)

    try {
        // 4. 上传代码
        if (options.zip) {
            // 压缩包方式上传
            const zipFileName = path.resolve(config.zip_temp_name) // 本地zip文件本地名称含路径
            const zipFilePureName = path.basename(zipFileName) // zip文件名

            // 服务器部署目录
            const serverDeployDir = config.remote_dir.endsWith('/') ? config.remote_dir : `${config.remote_dir}/`
            const serverZipFileName = `${serverDeployDir}${zipFilePureName}` // 服务器上zip文件完整文件名含路径

            // 4.1 上传压缩包
            await shellUploadFile(connect, zipFileName, serverZipFileName)
            console.log(`[****************----] 压缩包上传完成 ${config.remote_dir} [${time.next()}ms]`)

            // 4.2 服务器解压 (这里必须先cd再unzip，否则会解压报错)
            await shellExecCmd(connect, `cd ${serverDeployDir} && unzip -o ${zipFilePureName} \nexit\n`)
            console.log(`[******************--] 服务器解压完成 [${time.next()}ms]`)

            // 4.3 删除服务器压缩文件
            await shellExecCmd(connect, `rm -f ${serverZipFileName}\nexit\n`)

            // 4.4 删除本地zip文件
            fs.rmSync(zipFileName)
        } else {
            // dist目录方式上传
            await shellUploadFile(connect, buildOutDir, config.remote_dir)
        }

        // 5. pm2准备
        if (config.pm2?.after_cmd) {
            await shellExecCmd(connect, `cd ${config.remote_dir} \n${config.pm2.after_cmd} \nexit\n`)
            console.log(`[******************--] 已执行pm2指令:${config.pm2.after_cmd} [${time.next()}ms]`)
        }

        console.log(`[********************] 部署成功 [${time.next()}ms] [total ${time.done() / 1000}s]`)
    } catch (e) {
        console.error(`[!!!] 出错了:${e}`)
    } finally {
        connect.end() // 一定关闭连接，释放cmd
    }
}

/** 记录耗时 */
function getDisplayTime(date) {
    let cur = date
    return {
        start() {
            return `${date.getHours()}:${date.getMinutes()}:${date.getSeconds()} ${date.getMilliseconds()}`
        },
        next() {
            const now = new Date()
            const diff = now.valueOf() - cur.valueOf()
            cur = now
            return diff
        },
        done() {
            const now = new Date()
            return now.valueOf() - date.valueOf()
        },
    }
}

/** 递归清空目录 */
async function fsRmDir(path) {
    if (path.endsWith('/')) {
        path = path.slice(0, path.length - 1)
    }
    var files = []
    if (fs.existsSync(path)) {
        files = fs.readdirSync(path)
        files.forEach(function (file, index) {
            var curPath = path + '/' + file
            if (fs.statSync(curPath).isDirectory()) {
                // recurse
                fsRmDir(curPath)
            } else {
                // delete file
                fs.unlinkSync(curPath)
            }
        })
        await new Promise((resolve) => setTimeout(resolve, 500))
        try {
            fs.rmdirSync(path)
        } catch {}
    }
}

/** 上传一个文件（或目录） */
async function shellUploadFile(connect, localPath, remotePath) {
    const sftp = await new Promise((resolve, reject) => {
        connect.sftp((err, sftp) => {
            if (err) {
                console.error(`[!!!] 服务器连接sftp失败:${err}`)
                reject(err)
            }
            resolve(sftp)
        })
    })

    const stat = fs.statSync(localPath)
    if (stat.isFile()) {
        // 上传单个文件
        return new Promise((resolve, reject) => {
            const remoteDir = path.dirname(remotePath).replaceAll(path.sep, '/')
            sftp.mkdir(
                remoteDir,
                {
                    mode: '0644',
                    recursive: true,
                },
                (err, res) => {
                    sftp.fastPut(localPath, remotePath, (err, res) => {
                        if (err) {
                            console.error(`[!!!] 服务器sftp上传失败:${err}`)
                            return reject(err)
                        }
                        return resolve(remotePath)
                    })
                }
            )
        })
    }

    const fileList = scanDirectory(localPath)

    const promiseList = fileList.map((localFileName, index) => {
        const localFile = path.resolve(localPath, localFileName)
        const remoteFile = `${remotePath}/${localFileName}`
        return new Promise((resolve, reject) => {
            // 检查目录是否存在
            const remoteDir = path.dirname(remoteFile).replaceAll(path.sep, '/')
            sftp.mkdir(
                remoteDir,
                {
                    mode: '0644',
                    recursive: true,
                },
                (err, res) => {
                    sftp.fastPut(localFile, remoteFile, (err, res) => {
                        if (err) {
                            console.error(`[!!!] 服务器sftp上传失败:${err}`)
                            return reject(err)
                        }
                        return resolve(remoteFile)
                    })
                }
            )
        })
    })

    return Promise.all(promiseList)
}

/**
 * 扫描一个目录，返回其中所有递归的文件数组
 * @param {string} directoryPath 要扫描的本地路径，最好是绝对路径
 * @param {string} sep 结果的文件分隔符，默认/
 * @returns {array}
 */
function scanDirectory(directoryPath, sep = '/') {
    const fileList = [] // 存放相对路径的数组
    function traverseDir(dirPath, relativePath = '') {
        const files = fs.readdirSync(dirPath) // 读取当前目录下的文件列表
        for (const file of files) {
            const filePath = path.join(dirPath, file)
            const stat = fs.statSync(filePath)

            if (stat.isFile()) {
                // 计算相对路径
                const relativeFilePath = path.relative(directoryPath, filePath)
                fileList.push(relativeFilePath.replaceAll(path.sep, sep))
            } else if (stat.isDirectory()) {
                // 递归调用自身处理子目录，并更新相对路径
                traverseDir(filePath, path.join(relativePath, file))
            }
        }
    }
    traverseDir(directoryPath) // 开始扫描指定目录
    return fileList // 返回包含所有相对文件路径的数组
}

/**
 * 服务器执行指令
 * @param {SSH2Client} connect 连接实例
 * @param {string|Function} cmd 指令，可以是字符串或回调函数，回调函数会传入 stream
 * @param {boolean} showStdout 是否打印回执
 */
function shellExecCmd(connect, cmd, showStdout = false) {
    return new Promise((resolve, reject) => {
        connect.shell((err, stream) => {
            if (err) throw err
            stream
                .on('close', (code, signal) => {
                    if (code) return reject()
                    return resolve()
                })
                .on('data', (data) => {
                    if (showStdout) process.stdout.write(data) // 追加的写法
                })

            if (typeof cmd == 'string') {
                stream.write(cmd)
            } else {
                cmd(stream)
            }
        })
    })
}
